iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
JavaScript

重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!系列 第 5

Day5 推箱子4:跟隨使用者的體驗來打造隨機地圖!

  • 分享至 

  • xImage
  •  

在這個 AI 的時代,目前最主流的想法大概就是要先擬定規格,感覺好像所有的一切都要先想得很完整,或至少要有一個清晰的藍圖,剩下的再交給 AI 去執行。

當然我覺得這是很好的做法,作為一個 SOP 控,我習慣什麼事情都先做好計畫,每一步每一步該怎麼做,都要先設計好,這樣也能更好的 review AI 寫的內容、獲得更高的效率。

但如果有時候其實一開始想不到那麼多呢,有時候那一些規格會不會反而限制住了我們的想像力,同時也讓打造一個有趣的事物變得不那麼有趣了呢?

這就是今天的主題,作為遊戲的開發者同時也是第一個玩家,遇到不滿意的地方就來問問自己不滿意在哪裡,然後提出可能的方案,或許就能漸漸的打造出一個自己喜歡的遊戲也不一定呢!

可能性發想

回想一下前一天我們做好的遊戲,地圖永遠固定成下面這樣:

const map = [
  "########",
  "#      #",
  "#  .   #",
  "#  $   #",
  "#  @   #",
  "#      #",
  "########",
];

這就導致了每一次玩的時候都只有一個關卡,不管怎麼玩都是同一個遊戲體驗,當然是過不了第一位玩家(我自己)這一關。

那麼就從這個玩家的角度來寫下我對這件事情的期待:要打造一個一直有新鮮感的遊戲。

對於打造一個一直有新鮮感的遊戲,有哪些做法呢?

  1. 做很多個地圖?隨機選取一個?但是這個方法是手工去製作地圖,所以終究會有上限,如果重複執行個幾次很快就會發現都是一樣的了。
  2. 自動生成關卡:如果試著自動生成不同位置的角色、箱子和目標點呢?這個好像是更符合我想像中的做法。

那我們就擬定第一版先做到角色、箱子、目標點可以隨機出現在不同位置好了。

拆解步驟

一樣先來思考我們可以怎麼拆解成小步驟

  1. 先開一個函式專門畫出地圖,來取代固定的 map 陣列
  2. 用這個函式畫一個空地圖,傳入不同尺寸就可以有不同大小的地圖
  3. 角色和箱子、目標點隨機出現在範圍內而且不重複

行前準備

由於我們之後有想要改地圖的大小,所以我們先做個簡單的事前預備,這兩個地方原本是先預留七行,並將游標往上移七行,我們必須動態改成以 map 的行數來計算要移動幾行

process.stdout.write("\x1b[7A");
process.stdout.write("\n".repeat(7));

//改成下面這樣

process.stdout.write(`\x1b[${map.length}A`);
process.stdout.write("\n".repeat(map.length));

按照傳入的格數來畫一個空地圖

第一步沒辦法想得太遠,就先從創建函式並 return 回同一個陣列開始吧。

這邊做法其實很簡單,就是把原本的 map 陣列丟進去函式並 return 出來,再用一個 map 變數來儲存執行結果。

function createMap() {
    const map = [
        "########",
        "#      #",
        "#  .   #",
        "#  $   #",
        "#  @   #",
        "#      #",
        "########",
    ];

    return map
}

const map = createMap();

這樣就不影響後續的程式碼執行了,測試一下遊戲正常運作,我們就可以繼續來拆解函式內部的流程

接著我想嘗試來畫出牆壁,之前我們是用字串陣列的方式, y 的每一個項目代表一行,行內每一個字元就是 x,為了做更精準的定位,我想改成雙層陣列,但其實寫法差不多

function createMap(width,height) {
    // 一樣先有一個陣列來儲存整個地圖
    const map = [];
 
    // 熟悉的雙層迴圈,這次是直接用寬高的數值來計算
    for (let y = 0 ; y < height ; y++){
   
        // 用來記錄這一行的每一個座標
        const row = [];
        
        for (let x = 0; x <width ; x++){

            // 判斷是不是邊邊
            const isBorder = y === 0 || y === height -1 || x === 0 || x === width -1;
            if(isBorder){
                row.push('#')
            }else{
                row.push(' ')
            }

        }

        map.push(row)
    }

    return map
}

可以先試著印出來看看

console.log(createMap(5,5))
[
  [ '#', '#', '#', '#', '#' ],
  [ '#', ' ', ' ', ' ', '#' ],
  [ '#', ' ', ' ', ' ', '#' ],
  [ '#', ' ', ' ', ' ', '#' ],
  [ '#', '#', '#', '#', '#' ]
]

這樣我們就得到了一個用雙層陣列描述的地圖外框,而且是按照我們所設定的 x 和 y 軸數字長成的。

隨機找一個位置來放 player

我們之前寫 player 是透過解析地圖,找到 player 的位置並儲存在 player 變數, render 函式最終是以 player 的座標去做渲染。也就是說,其實我們最終想要得到的無非就是 player 的座標,而不是他必須被畫在地圖中

所以我們現在不需要把 player 畫進去地圖裡,剛剛的地圖我們維持空白的場地就好,我們只需要去找到允許的座標,隨機為 player 設定一個座標點

步驟拆解如下

  1. 制定一個空的陣列,用於儲存找到的空白座標
  2. 透過雙重迴圈逐一找到空白座標並儲存起來
  3. 隨機挑一個
  4. 回傳

首先我們先來完成前兩步

// 把剛剛透過 createMap(width,height) 完成的 map 傳進來
function pickRandomEmptyCell(map){
    // 用來儲存空白格
    const emptyCell = [];
    //雙重陣列逐一查看是否為空白格
    for (let y = 0 ; y < map.length ; y++){
        
        for (let x = 0; x <map[y].length ; x++){
            // 如果是的話儲存回 emptyCell
            if(map[y][x]===" "){
                emptyCell.push({x,y})
            }
        }
    }
    // 先印出來看看
    console.log(emptyCell)
}

pickRandomEmptyCell(map)
[
  { x: 1, y: 1 },
  { x: 2, y: 1 },
  { x: 3, y: 1 },
  { x: 1, y: 2 },
  { x: 2, y: 2 },
  { x: 3, y: 2 },
  { x: 1, y: 3 },
  { x: 2, y: 3 },
  { x: 3, y: 3 }
] 

對照前面的地圖檢查,確認是正確的之後就可以往下走。

接下來隨機從中選一格:

// 從 emptyCell 的長度中選擇一個數字
const randomIndex = Math.floor(Math.random() * emptyCell.length);
console.log(emptyCell[randomIndex]);

Math.random() * emptyCell.length 的意思是從 emptyCell 的長度中隨機產生一個小於 emptyCell.length的小數,比如說如果陣列長度是 10 ,那隨機產生的小數可能是 5.5,但是因為 index 需要是整數才能夠傳入 emptyCell 陣列中取值,所以我們需要取到整數,這時就在外面包一層 Math.floor 就可以了

接著我們把它存入 player ,然後試著渲染看看畫面

let player = pickRandomEmptyCell(map);
function render() {

    let frame = ''

    for (let y = 0; y < map.length; y++) {
        let row = ''

        for (let x = 0; x < map[y].length; x++) {

            const cell = map[y][x]

            if (player.x === x && player.y === y) {
                row += `\x1b[33m@\x1b[0m`
            // } else if (box.x === x && box.y === y) {
            //     row += `\x1b[34m$\x1b[0m`
            // } else if (goal.x === x && goal.y === y) {
            //     row += `\x1b[31m.\x1b[0m`
            // } else if (cell === '@' || cell === '$' || cell === '.') {
            //     row += ' '
            } else {
                row += `\x1b[32m${cell}\x1b[0m`
            }

        }

        frame += row + '\n'
    }
    
    process.stdout.write(`\x1b[${map.length}A`);
    process.stdout.write(frame)

}

process.stdout.write("\n".repeat(map.length));
render()

連續跑個幾次,發現 player 會在不同的位置出現,這樣就成功囉

隨機產生 Box 和 Goal

按照同樣的方式,但是必須要避開 player,於是我想在 pickRandomEmptyCell 這個函式中加入第二個變數是已經被占用的格子,以陣列的方式呈現

// occupied 預設是一個空陣列,這樣也可以避免萬一沒傳入變數的情況,不會報錯
function pickRandomEmptyCell(map , occupied = []){
    // 用來儲存空白格
    const emptyCell = [];

    for (let y = 0 ; y < map.length ; y++){
        
        for (let x = 0; x <map[y].length ; x++){
            // 判斷是不是已被占用
            const isOccupied = occupied.some(cell => cell.x === x && cell.y === y);
            // 如果該位置是空的而且尚未被占用才將這個數值儲存到 emptyCell
            if(map[y][x]===" " && !isOccupied){
                emptyCell.push({x,y})
            }
        }
    }

    const randomIndex = Math.floor(Math.random() * emptyCell.length);
    return emptyCell[randomIndex]
}

let player = pickRandomEmptyCell(map); // 用來儲存玩家位置
let box = pickRandomEmptyCell(map ,[player]); // 用來儲存箱子位置
let goal = pickRandomEmptyCell(map ,[player,box]); // 用來儲存目標點位置

記得剛剛 render 函式內被註解起來的項目要打開,這時我們再 render 一次,就可以看見他們都出現在不同的位置囉!!可以試著玩玩看。

從玩家體驗的角度發現問題

在遊玩過程中,我發現當生成了以下的兩種位置,會導致我完全無法進行遊戲:

  1. 箱子在四個死角的時候:由於昨天我們撰寫的失敗判定規則,萬一生成在角落,玩家會在一打開遊戲就被判定失敗。
  2. 箱子靠邊,但是目標點不靠邊的時候:最終就是只能推到死角,然後就會回到上述的狀況。

萬一生成了這兩種關卡,就只有失敗的可能性,會大大影響玩家的體驗,因此最短的路徑就是直接讓它不要被生成出來。

處理四個死角

因為我們的勝負判定是一旦箱子出現在四個死角,就會失敗,而如果遊戲生成時就將箱子出現在死角,角色會在移動第一步時失敗(因為此時才觸發 render 函式內的勝負判定邏輯)

所以處理的方法是:避免 box 出現在四個死角。

所以我們可以來嘗試在生成 box 的時候做一個檢查,如果位於死角就重新生成。

// 新增一個函式用來選擇 box 座標
function boxPicker() {

    // 先用老方法生成一個 box
    let box = pickRandomEmptyCell(map, [player]);

    // 接著判斷是否在各個邊邊
    const top = map[box.y - 1][box.x] === '#';
    const bottom = map[box.y + 1][box.x] === '#';
    const left = map[box.y][box.x - 1] === '#';
    const right = map[box.y][box.x + 1] === '#';
    // 判斷是否在死角
    const isDeadlock = (top || bottom) && (left || right);

    // 如果在死角的話就重新跑一次這個流程
    if (isDeadlock) {
        return boxPicker();
    };

    // 最後把座標回傳
    return box;

}

let player = pickRandomEmptyCell(map);
let box = boxPicker(); // 用來儲存箱子位置
let goal = pickRandomEmptyCell(map, [player, box]);

來玩個十次看看是不是還會出現死角吧!!

箱子靠邊但是目標點不靠邊

因為剛剛已經有寫過是否靠邊的判斷了,此時我們把 goal 也加進來一起做判斷

// 首先移動一下生成的順序
let player = pickRandomEmptyCell(map);
let goal = pickRandomEmptyCell(map, [player]); // 先生成目標點
let box = boxPicker(); // 再生成箱子,確保執行 boxPicker 時可以取得 goal

接著我們來改寫 boxPicker

function boxPicker() {
    // 這裡要避開 player 和 goal 兩個座標
    let box = pickRandomEmptyCell(map, [player,goal]);

    // 這邊是判斷箱子是否靠邊
    const top = map[box.y - 1][box.x] === '#';
    const bottom = map[box.y + 1][box.x] === '#';
    const left = map[box.y][box.x - 1] === '#';
    const right = map[box.y][box.x + 1] === '#';
    const isDeadlock = (top || bottom) && (left || right);

    // 這邊加上如果 goal 不在同一個邊邊上的話要重抽的邏輯
    if (isDeadlock || (top && box.y !== goal.y ) || (bottom && box.y !== goal.y ) ||  (left && box.x !== goal.x) ||  (right && box.x !== goal.x)) {
        return boxPicker();
    }

    return box;

}

這樣就先排除了目前遊玩時發現的兩種無解狀況。至於是不是所有生成出來的地圖都有解呢?老實說,我現在也不知道!如果之後又玩到新的問題,再繼續想辦法處理吧!

另外雖然說這不是這次主題的目的,我們還是可以稍微將共用的邏輯抽成函式:我想制訂一個 checkBox 函式,把先前在 process.stdin.on 監聽器 和 boxPicker 裡面用於判斷 box 位置的幾個參數都放進來:

function checkBox (map,box) {
    const top = map[box.y - 1][box.x] === '#';
    const bottom = map[box.y + 1][box.x] === '#';
    const left = map[box.y][box.x - 1] === '#';
    const right = map[box.y][box.x + 1] === '#';
    const isDeadlock = (top || bottom) && (left || right);

    const position = {top,bottom,left,right,isDeadlock}

    return position
}

接著把它放回到這兩個地方來使用,利用這一句解構的語法就可以把這五個參數都取出來了

const { top,bottom,left,right,isDeadlock } = checkBox (map,box)

今天我們先以玩家的角度實際體驗遊戲,發現固定關卡缺乏新鮮感,再從想到的可能方案中選擇目前最適合執行的一種。實作以後,又透過遊玩發現隨機生成可能產生無解關卡,於是繼續修正規則。

其實也可以說很多事情,只靠事前規劃是不夠的,藉由小步測試、迭代開發也正是當代敏捷開發的一大重點吧。

也就在這個反覆遊玩的 moment!我突然想到一個好點子了!

如果我們設計一個按鈕讓箱子可以彈出來呢,大家可以先想一想要如何做,明天我們再一起來完成!

如果你想要看完整的程式碼,可以參考這裡:Day5 程式碼


上一篇
Day4 推箱子3:用自己的語言來描述,讓角色可以被操控吧!
下一篇
Day6 推箱子5:不知道想法好不好,就打造原型!
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言